Skip to content

✨ Add Content Calendar view with filter support#26430

Open
brandenwagner wants to merge 13 commits intoTryGhost:mainfrom
PureIntellect:branden/content-calendar
Open

✨ Add Content Calendar view with filter support#26430
brandenwagner wants to merge 13 commits intoTryGhost:mainfrom
PureIntellect:branden/content-calendar

Conversation

@brandenwagner
Copy link

@brandenwagner brandenwagner commented Feb 16, 2026

Introducing a full Content Calendar experience for Posts, moving beyond scheduled-only items.
image

What’s included

  • Multi-filter support to the calendar
  • Same filter experience across the rest of the posts pages
  • Persisted filters in URL search params so calendar state is shareable and reload-safe.
  • Added status legend + visual styling per post status in the grid.
  • Added flexible date fallback logic for calendar placement:
  • Drafts prefer updated_at, then created_at, then published_at.
  • Other statuses prefer published_at, then updated_at, then created_at.
  • Added sort support in calendar utilities (published_at asc/desc, updated_at desc).
  • Updated unit tests to cover new status/date/sort behavior and missing-date edge cases.

Please take a minute to explain the change you're making:

  • Why are you making it?
    • Its been discussed before on the forums but no one (to my knowledge) has started working on this feature. It helps me see the schedule and keep things on track. Especially when im working on a series by tags
  • What does it do?
    • Its a calendar display of posts
  • Why is this something Ghost users or developers need?
    • Its a visual interpretation of the existing post listing pages.

Please check your PR against these items:

  • I've read and followed the Contributor Guide
  • I've explained my change
  • I've written an automated test to prove my change works

Note

Medium Risk
Adds a sizable new view and query/filter logic that impacts how posts are fetched and grouped by date; risk is mitigated by isolated routing/UI changes and new unit test coverage.

Overview
Adds a new posts/calendar route and UI that renders posts in a month grid, including per-status styling, month navigation, and empty/error/loading states.

The calendar view supports URL-synced filters (type/visibility/author/tag) and sorting, builds an NQL date-range filter with sensible per-status date fallbacks, and includes unit tests for timezone bucketing, ordering, and missing-date edge cases. The admin sidebar now links to Calendar under Posts and Post API typing is extended with created_at/updated_at (plus a small dev MySQL healthcheck tweak in compose.dev.yaml).

Written by Cursor Bugbot for commit 9260747. This will update automatically on new commits. Configure here.

ref https://github.com/TryGhost/Ghost
Podman compose split the SQL healthcheck command argument and caused repeated "Unknown database '1'" failures, delaying ghost-dev startup readiness.
ref https://github.com/TryGhost/Ghost
Added a posts calendar route, view, and date utilities, plus unit tests and sidebar navigation so calendar pages are accessible from Admin.
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 16, 2026

Walkthrough

Adds calendar functionality for Posts: extends Post with optional created_at and updated_at; adds new admin API hooks for posts (browse, get, export, delete, search-index) in admin-x-framework; inserts a "Calendar" item in the Posts admin submenu and registers the posts/calendar route; implements a ContentCalendar React component with filters, month navigation, URL-synced state and editor links; adds timezone-aware calendar utilities (buildCalendarGrid, formatters, date helpers) and unit tests for those utilities; changes MySQL healthcheck to use mysqladmin ping with a 10s start_period.

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: adding a Content Calendar view with filter support, which is the primary focus of this changeset.
Description check ✅ Passed The description is directly related to the changeset, providing motivation, feature details, and test confirmation for the calendar feature being added.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Tip

Issue Planner is now in beta. Read the docs and try it out! Share your feedback on Discord.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Fix all issues with AI agents
Verify each finding against the current code and only fix it if needed.


In `@apps/posts/src/views/ContentCalendar/content-calendar.tsx`:
- Around line 321-327: The query currently passes limit: 'all' to useBrowsePosts
causing it to fetch every post matching calendarFilter; change the call to
constrain results by adding a date-range filter (e.g., startDate and endDate
spanning the visible month ± buffer) to the searchParams.filter before calling
useBrowsePosts so it only fetches posts for the calendar view; locate where
useBrowsePosts is invoked and where calendarFilter/calendarOrder are composed
and merge the computed dateRange into calendarFilter (or replace limit: 'all'
with a paginated/limited query) so the request returns only the relevant month’s
posts.
- Around line 448-487: Import the formatMonthLabel helper from the utils at the
top of the file and render a small, read-only month label between the navigation
buttons using the current month offset state (the same state modified by
setMonthOffset). Specifically, add an import for formatMonthLabel and insert a
span/div between the "Today" button and the Chevron buttons that calls
formatMonthLabel(monthOffset) (or the component's current offset variable) to
display the month/year string so users can see which month is shown.
- Around line 26-82: Add a short TODO comment above the LegacyFilterSelect
component explaining it’s a temporary compatibility component and describing the
intended migration path (e.g., replace LegacyFilterSelect with the Shade Select
component and remove ember-* CSS classes once the legacy admin is retired),
include any relevant tracking issue or ticket ID if available, and note why the
ember-* classes are currently preserved for visual parity; reference the
LegacyFilterSelect function/component name and the ember-* CSS classes (e.g.,
'ember-view', 'ember-basic-dropdown-trigger') so future contributors can find
and remove this temporary code during migration.

In `@apps/posts/src/views/ContentCalendar/utils/calendar.ts`:
- Around line 72-78: getTimestamp can return NaN for malformed date strings
which breaks sorting; update the getTimestamp function to validate the parsed
time (from new Date(value).getTime() or Date.parse) and return a safe fallback
(e.g., 0 or a clearly defined sentinel) when the parsed result is NaN or value
is invalid so comparisons remain stable—modify the body of getTimestamp to parse
the date, check isNaN(parsed) and return the fallback on failure, otherwise
return the parsed timestamp.

In `@apps/posts/test/unit/utils/content-calendar.test.ts`:
- Around line 64-76: Add a unit test that verifies the priority chain updated_at
→ created_at for draft posts: create a draft post via createPost with both
updated_at and created_at set to different dates, call buildCalendarGrid with
that post (same month/timeZone as existing tests), find the day by dateKey for
the updated_at date, and assert the grid places the post on the updated_at date
(showing updated_at wins over created_at).
🧹 Nitpick comments (3)
🤖 Fix all nitpicks with AI agents
Verify each finding against the current code and only fix it if needed.


In `@apps/posts/src/views/ContentCalendar/content-calendar.tsx`:
- Around line 26-82: Add a short TODO comment above the LegacyFilterSelect
component explaining it’s a temporary compatibility component and describing the
intended migration path (e.g., replace LegacyFilterSelect with the Shade Select
component and remove ember-* CSS classes once the legacy admin is retired),
include any relevant tracking issue or ticket ID if available, and note why the
ember-* classes are currently preserved for visual parity; reference the
LegacyFilterSelect function/component name and the ember-* CSS classes (e.g.,
'ember-view', 'ember-basic-dropdown-trigger') so future contributors can find
and remove this temporary code during migration.

In `@apps/posts/src/views/ContentCalendar/utils/calendar.ts`:
- Around line 72-78: getTimestamp can return NaN for malformed date strings
which breaks sorting; update the getTimestamp function to validate the parsed
time (from new Date(value).getTime() or Date.parse) and return a safe fallback
(e.g., 0 or a clearly defined sentinel) when the parsed result is NaN or value
is invalid so comparisons remain stable—modify the body of getTimestamp to parse
the date, check isNaN(parsed) and return the fallback on failure, otherwise
return the parsed timestamp.

In `@apps/posts/test/unit/utils/content-calendar.test.ts`:
- Around line 64-76: Add a unit test that verifies the priority chain updated_at
→ created_at for draft posts: create a draft post via createPost with both
updated_at and created_at set to different dates, call buildCalendarGrid with
that post (same month/timeZone as existing tests), find the day by dateKey for
the updated_at date, and assert the grid places the post on the updated_at date
(showing updated_at wins over created_at).
apps/posts/test/unit/utils/content-calendar.test.ts (1)

64-76: Consider adding a test for the full updated_at → created_at priority chain for drafts.

The current tests cover a draft with only updated_at (line 34) and one with only created_at (line 69), but there's no case where both are present to verify updated_at wins. A quick additional test would harden the fallback contract.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/posts/test/unit/utils/content-calendar.test.ts` around lines 64 - 76,
Add a unit test that verifies the priority chain updated_at → created_at for
draft posts: create a draft post via createPost with both updated_at and
created_at set to different dates, call buildCalendarGrid with that post (same
month/timeZone as existing tests), find the day by dateKey for the updated_at
date, and assert the grid places the post on the updated_at date (showing
updated_at wins over created_at).
apps/posts/src/views/ContentCalendar/content-calendar.tsx (1)

26-82: LegacyFilterSelect — naming hints at technical debt; consider a TODO or plan to migrate.

The component name and its use of ember-* CSS classes make it clear this is a stopgap for visual parity with the Ember admin. This is fine for now, but a brief comment about the intended migration path (e.g., replacing with a Shade <Select> component once the legacy admin is retired) would help future contributors.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/posts/src/views/ContentCalendar/content-calendar.tsx` around lines 26 -
82, Add a short TODO comment above the LegacyFilterSelect component explaining
it’s a temporary compatibility component and describing the intended migration
path (e.g., replace LegacyFilterSelect with the Shade Select component and
remove ember-* CSS classes once the legacy admin is retired), include any
relevant tracking issue or ticket ID if available, and note why the ember-*
classes are currently preserved for visual parity; reference the
LegacyFilterSelect function/component name and the ember-* CSS classes (e.g.,
'ember-view', 'ember-basic-dropdown-trigger') so future contributors can find
and remove this temporary code during migration.
apps/posts/src/views/ContentCalendar/utils/calendar.ts (1)

72-78: getTimestamp can return NaN for malformed date strings, breaking sort stability.

If value is a non-empty but invalid date string, new Date(value).getTime() returns NaN. Comparisons involving NaN always evaluate to false, which can produce nondeterministic sort results.

Low risk since values come from the API, but a one-line guard makes this bulletproof.

Proposed fix
 const getTimestamp = (value?: string) => {
     if (!value) {
         return 0;
     }
 
-    return new Date(value).getTime();
+    const ts = new Date(value).getTime();
+    return Number.isNaN(ts) ? 0 : ts;
 };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/posts/src/views/ContentCalendar/utils/calendar.ts` around lines 72 - 78,
getTimestamp can return NaN for malformed date strings which breaks sorting;
update the getTimestamp function to validate the parsed time (from new
Date(value).getTime() or Date.parse) and return a safe fallback (e.g., 0 or a
clearly defined sentinel) when the parsed result is NaN or value is invalid so
comparisons remain stable—modify the body of getTimestamp to parse the date,
check isNaN(parsed) and return the fallback on failure, otherwise return the
parsed timestamp.

ref TryGhost#26430

Reduced content-calendar fetch size by applying a month-window date filter and added an inline month/year label in navigation controls.
ref TryGhost#26430

Documented that LegacyFilterSelect and ember-* classes are temporary and should be removed when the app fully moves to Shade-native controls.
ref TryGhost#26430

Guarded getTimestamp against invalid date strings so sort comparisons stay deterministic when malformed values are present.
ref TryGhost#26430

Covered the case where draft posts have both updated_at and created_at to verify updated_at takes precedence in calendar placement.
@brandenwagner brandenwagner changed the title ✨ Content Calendar ✨ Add Content Calendar view with filter support Feb 16, 2026
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
Verify each finding against the current code and only fix it if needed.


In `@apps/posts/src/views/ContentCalendar/content-calendar.tsx`:
- Around line 144-153: The query range currently ends at the last moment of
month.month+1 (March 31) which misses trailing days shown in a 6-week calendar;
update getCalendarDateRangeFilter to compute rangeEnd as the last displayed day
of the calendar grid (i.e., the last day of month.month + 1 in 1-based terms)
and set it to that day's end-of-day timestamp (23:59:59.999) so
published_at/updated_at/created_at filters include trailing grid cells; adjust
the rangeEnd calculation (and keep rangeStart as-is) inside
getCalendarDateRangeFilter so the returned filter string uses the new rangeEnd.
- Around line 131-153: The two functions getLegendStatusesForType and
getCalendarDateRangeFilter are duplicated; remove the local definitions in
content-calendar.tsx and instead import these functions from the shared calendar
utils module where they already exist (ensure that module exports
getLegendStatusesForType and getCalendarDateRangeFilter), update any local
references to use the imported symbols, and delete the duplicate implementations
so there is a single source of truth.
- Around line 220-354: ContentCalendar is too large—extract the calendar grid
rendering, filter bar, and month navigation into smaller subcomponents to
improve readability and testability: create CalendarGrid (props: calendarDays,
siteTimezone, month, calendarOrder, posts), FilterBar (props: authorOptions,
tagOptions, legendStatuses, hasActiveFilters, selectedType, selectedVisibility,
selectedAuthor, selectedTag, selectedOrder, setFilterParam, clearFilters) and
MonthNavigation (props: monthLabel, monthOffset, setMonthOffset) and replace the
inline JSX with these components inside ContentCalendar while keeping existing
hooks and helper functions (useBrowsePosts, buildCalendarGrid,
buildCalendarFilter, getCalendarDateRangeFilter) intact so state and
search-param handlers (setFilterParam, clearFilters) continue to work.

In `@apps/posts/src/views/ContentCalendar/utils/calendar.ts`:
- Around line 55-60: getDateKeyInTimezone and formatPostTime must guard against
invalid dateInput before calling getDatePartsInTimezone or Intl APIs; add a
check like const date = new Date(dateInput); if (isNaN(date.getTime())) throw
new RangeError(`Invalid dateInput passed to getDateKeyInTimezone`); (and the
same guard in formatPostTime) so you avoid
Intl.DateTimeFormat.format/formatToParts throwing unexpectedly; use the same
validation approach as getTimestamp and include a clear error message mentioning
the function name.
🧹 Nitpick comments (3)
🤖 Fix all nitpicks with AI agents
Verify each finding against the current code and only fix it if needed.


In `@apps/posts/src/views/ContentCalendar/content-calendar.tsx`:
- Around line 131-153: The two functions getLegendStatusesForType and
getCalendarDateRangeFilter are duplicated; remove the local definitions in
content-calendar.tsx and instead import these functions from the shared calendar
utils module where they already exist (ensure that module exports
getLegendStatusesForType and getCalendarDateRangeFilter), update any local
references to use the imported symbols, and delete the duplicate implementations
so there is a single source of truth.
- Around line 220-354: ContentCalendar is too large—extract the calendar grid
rendering, filter bar, and month navigation into smaller subcomponents to
improve readability and testability: create CalendarGrid (props: calendarDays,
siteTimezone, month, calendarOrder, posts), FilterBar (props: authorOptions,
tagOptions, legendStatuses, hasActiveFilters, selectedType, selectedVisibility,
selectedAuthor, selectedTag, selectedOrder, setFilterParam, clearFilters) and
MonthNavigation (props: monthLabel, monthOffset, setMonthOffset) and replace the
inline JSX with these components inside ContentCalendar while keeping existing
hooks and helper functions (useBrowsePosts, buildCalendarGrid,
buildCalendarFilter, getCalendarDateRangeFilter) intact so state and
search-param handlers (setFilterParam, clearFilters) continue to work.

In `@apps/posts/src/views/ContentCalendar/utils/calendar.ts`:
- Around line 55-60: getDateKeyInTimezone and formatPostTime must guard against
invalid dateInput before calling getDatePartsInTimezone or Intl APIs; add a
check like const date = new Date(dateInput); if (isNaN(date.getTime())) throw
new RangeError(`Invalid dateInput passed to getDateKeyInTimezone`); (and the
same guard in formatPostTime) so you avoid
Intl.DateTimeFormat.format/formatToParts throwing unexpectedly; use the same
validation approach as getTimestamp and include a clear error message mentioning
the function name.
apps/posts/src/views/ContentCalendar/utils/calendar.ts (1)

55-60: No guard against invalid dateInputIntl.DateTimeFormat.format() throws RangeError on Invalid Date.

getTimestamp (line 72) already guards against invalid dates, but getDateKeyInTimezone and formatPostTime do not. If an unexpected value slips through (e.g., an empty string or malformed ISO string), new Date(dateInput) produces Invalid Date and formatToParts / format will throw a RangeError at runtime. mapPostsByDay filters out posts with no occursAt, so the risk is low in the current call path, but the public export surface is unprotected.

Proposed guard
 export const getDateKeyInTimezone = (dateInput: string, timeZone: string) => {
     const date = new Date(dateInput);
+    if (Number.isNaN(date.getTime())) {
+        return '';
+    }
     const {year, month, day} = getDatePartsInTimezone(date, timeZone);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/posts/src/views/ContentCalendar/utils/calendar.ts` around lines 55 - 60,
getDateKeyInTimezone and formatPostTime must guard against invalid dateInput
before calling getDatePartsInTimezone or Intl APIs; add a check like const date
= new Date(dateInput); if (isNaN(date.getTime())) throw new RangeError(`Invalid
dateInput passed to getDateKeyInTimezone`); (and the same guard in
formatPostTime) so you avoid Intl.DateTimeFormat.format/formatToParts throwing
unexpectedly; use the same validation approach as getTimestamp and include a
clear error message mentioning the function name.
apps/posts/src/views/ContentCalendar/content-calendar.tsx (2)

131-153: getLegendStatusesForType and getCalendarDateRangeFilter appear duplicated in apps/posts/src/utils/calendar.ts.

The relevant code snippets show identical functions at apps/posts/src/utils/calendar.ts lines 61–69. If that file exists alongside this one, these should be imported from a single shared module rather than defined in two places.

#!/bin/bash
# Verify if these functions exist in both locations
rg -n 'getLegendStatusesForType|getCalendarDateRangeFilter' apps/posts/src --type ts -g '!*.test.*'
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/posts/src/views/ContentCalendar/content-calendar.tsx` around lines 131 -
153, The two functions getLegendStatusesForType and getCalendarDateRangeFilter
are duplicated; remove the local definitions in content-calendar.tsx and instead
import these functions from the shared calendar utils module where they already
exist (ensure that module exports getLegendStatusesForType and
getCalendarDateRangeFilter), update any local references to use the imported
symbols, and delete the duplicate implementations so there is a single source of
truth.

220-354: Consider extracting sub-components to reduce component size.

The ContentCalendar component is ~370 lines long. The calendar grid rendering (lines 528–579), filter bar (lines 356–423), and month navigation (lines 467–509) are natural extraction candidates. This would improve readability and testability.

Not urgent for a first iteration, but worth noting for follow-up.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/posts/src/views/ContentCalendar/content-calendar.tsx` around lines 220 -
354, ContentCalendar is too large—extract the calendar grid rendering, filter
bar, and month navigation into smaller subcomponents to improve readability and
testability: create CalendarGrid (props: calendarDays, siteTimezone, month,
calendarOrder, posts), FilterBar (props: authorOptions, tagOptions,
legendStatuses, hasActiveFilters, selectedType, selectedVisibility,
selectedAuthor, selectedTag, selectedOrder, setFilterParam, clearFilters) and
MonthNavigation (props: monthLabel, monthOffset, setMonthOffset) and replace the
inline JSX with these components inside ContentCalendar while keeping existing
hooks and helper functions (useBrowsePosts, buildCalendarGrid,
buildCalendarFilter, getCalendarDateRangeFilter) intact so state and
search-param handlers (setFilterParam, clearFilters) continue to work.

Comment on lines 144 to 153
const getCalendarDateRangeFilter = (month: CalendarMonth): string => {
const rangeStart = new Date(Date.UTC(month.year, month.month - 2, 1, 0, 0, 0, 0)).toISOString();
const rangeEnd = new Date(Date.UTC(month.year, month.month + 1, 0, 23, 59, 59, 999)).toISOString();

const rangeForField = (field: string) => {
return `${field}:>='${rangeStart}'+${field}:<='${rangeEnd}'`;
};

return `(${rangeForField('published_at')},${rangeForField('updated_at')},${rangeForField('created_at')})`;
};
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify the date range calculation for February 2026
node -e "
const month = {year: 2026, month: 2}; // February, 1-based
const rangeStart = new Date(Date.UTC(month.year, month.month - 2, 1, 0, 0, 0, 0));
const rangeEnd = new Date(Date.UTC(month.year, month.month + 1, 0, 23, 59, 59, 999));
console.log('=== Query Date Range ===');
console.log('Range start:', rangeStart.toISOString());
console.log('Range end:', rangeEnd.toISOString());

// Check the calendar grid for February 2026
console.log('\n=== Calendar Grid Analysis ===');
const firstDay = new Date(Date.UTC(month.year, month.month - 1, 1));
const firstDayOfWeek = firstDay.getUTCDay(); // 0 = Sunday
console.log('Feb 1, 2026 is:', ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][firstDayOfWeek]);
console.log('First day of week index:', firstDayOfWeek);

const daysInMonth = new Date(Date.UTC(month.year, month.month, 0)).getUTCDate();
console.log('Days in Feb 2026:', daysInMonth);

// Calculate grid span: 6 weeks = 42 days
const gridSize = 42;
const totalDaysNeeded = firstDayOfWeek + daysInMonth;
const trailingDays = gridSize - totalDaysNeeded;
console.log('Days from previous month (grid padding):', firstDayOfWeek);
console.log('Days of current month:', daysInMonth);
console.log('Days from next month (trailing):', trailingDays);

console.log('\nGrid starts:', new Date(Date.UTC(month.year, month.month - 1, 1 - firstDayOfWeek)).toISOString().split('T')[0]);
console.log('Grid ends:', new Date(Date.UTC(month.year, month.month, daysInMonth + trailingDays)).toISOString().split('T')[0]);
"

Repository: TryGhost/Ghost

Length of output: 408


Extend rangeEnd to cover trailing next-month days shown in the calendar grid.

For February 2026, the calendar grid spans Feb 1–28 (starting Sunday with no padding) plus 14 trailing days into March, but extends visually to April 11 in a 6-week calendar layout. The current query range ends March 31, leaving April 1–11 cells empty even if posts exist on those dates.

The fix should extend rangeEnd to the last displayed day of the grid:

Proposed fix
 const getCalendarDateRangeFilter = (month: CalendarMonth): string => {
     const rangeStart = new Date(Date.UTC(month.year, month.month - 2, 1, 0, 0, 0, 0)).toISOString();
-    const rangeEnd = new Date(Date.UTC(month.year, month.month + 1, 0, 23, 59, 59, 999)).toISOString();
+    const rangeEnd = new Date(Date.UTC(month.year, month.month + 2, 0, 23, 59, 59, 999)).toISOString();

This shifts rangeEnd to the last day of month month.month + 1 (1-based), ensuring trailing grid cells are included.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const getCalendarDateRangeFilter = (month: CalendarMonth): string => {
const rangeStart = new Date(Date.UTC(month.year, month.month - 2, 1, 0, 0, 0, 0)).toISOString();
const rangeEnd = new Date(Date.UTC(month.year, month.month + 1, 0, 23, 59, 59, 999)).toISOString();
const rangeForField = (field: string) => {
return `${field}:>='${rangeStart}'+${field}:<='${rangeEnd}'`;
};
return `(${rangeForField('published_at')},${rangeForField('updated_at')},${rangeForField('created_at')})`;
};
const getCalendarDateRangeFilter = (month: CalendarMonth): string => {
const rangeStart = new Date(Date.UTC(month.year, month.month - 2, 1, 0, 0, 0, 0)).toISOString();
const rangeEnd = new Date(Date.UTC(month.year, month.month + 2, 0, 23, 59, 59, 999)).toISOString();
const rangeForField = (field: string) => {
return `${field}:>='${rangeStart}'+${field}:<='${rangeEnd}'`;
};
return `(${rangeForField('published_at')},${rangeForField('updated_at')},${rangeForField('created_at')})`;
};
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/posts/src/views/ContentCalendar/content-calendar.tsx` around lines 144 -
153, The query range currently ends at the last moment of month.month+1 (March
31) which misses trailing days shown in a 6-week calendar; update
getCalendarDateRangeFilter to compute rangeEnd as the last displayed day of the
calendar grid (i.e., the last day of month.month + 1 in 1-based terms) and set
it to that day's end-of-day timestamp (23:59:59.999) so
published_at/updated_at/created_at filters include trailing grid cells; adjust
the rangeEnd calculation (and keep rangeStart as-is) inside
getCalendarDateRangeFilter so the returned filter string uses the new rangeEnd.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
apps/posts/src/views/ContentCalendar/utils/calendar.ts (1)

106-121: sortCalendarPosts mutates the input array via .sort().

Currently safe because the only call site passes a freshly-created .filter() result, but the function signature suggests a pure transform. A stale-reference bug could surface if a future caller passes a shared array.

Proposed fix — sort a shallow copy
 const sortCalendarPosts = (posts: CalendarPost[], order: CalendarPostOrder): CalendarPost[] => {
-    return posts.sort((a, b) => {
+    return [...posts].sort((a, b) => {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/posts/src/views/ContentCalendar/utils/calendar.ts` around lines 106 -
121, sortCalendarPosts currently mutates its input by calling posts.sort();
change it to sort a shallow copy instead so the original array isn't mutated:
create a copy of the posts array (e.g., via [...posts] or posts.slice()) and
call .sort() on that copy before returning it; update the function around the
posts parameter and the sort call in sortCalendarPosts to operate on the copied
array so callers receive a new sorted array without modifying the input.
apps/posts/src/views/ContentCalendar/content-calendar.tsx (1)

371-377: Add a fields parameter to optimize the query payload.

The query fetches full post objects but the calendar only consumes id, title, status, published_at, updated_at, and created_at. The Ghost Admin API supports a fields parameter that can meaningfully reduce payload size, especially with limit: 'all'.

Proposed change
 const {data, isError, isLoading} = useBrowsePosts({
     searchParams: {
         filter: postQueryFilter,
         limit: 'all',
-        order: calendarOrder
+        order: calendarOrder,
+        fields: 'id,title,status,published_at,updated_at,created_at'
     }
 });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/posts/src/views/ContentCalendar/content-calendar.tsx` around lines 371 -
377, The browse posts query is fetching full post objects but the calendar only
needs a small subset; update the call to useBrowsePosts to include a
searchParams.fields parameter listing
"id,title,status,published_at,updated_at,created_at" (comma-separated string)
alongside the existing filter/limit/order (keep postQueryFilter and
calendarOrder intact) so the request payload is smaller when limit: 'all'.
Ensure the fields key is added to the same searchParams object passed into
useBrowsePosts.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@apps/posts/src/views/ContentCalendar/content-calendar.tsx`:
- Around line 163-172: Remove the leftover duplicate review marker and/or
redundant comment in the getCalendarDateRangeFilter implementation: delete the
stray "[duplicate_comment]" note and any duplicated explanatory text surrounding
the function so the code and its comment are not repeated; keep the existing
date-range logic (rangeStart/rangeEnd and rangeForField) intact in
getCalendarDateRangeFilter and ensure only a single, clear comment or none
remains.

---

Nitpick comments:
In `@apps/posts/src/views/ContentCalendar/content-calendar.tsx`:
- Around line 371-377: The browse posts query is fetching full post objects but
the calendar only needs a small subset; update the call to useBrowsePosts to
include a searchParams.fields parameter listing
"id,title,status,published_at,updated_at,created_at" (comma-separated string)
alongside the existing filter/limit/order (keep postQueryFilter and
calendarOrder intact) so the request payload is smaller when limit: 'all'.
Ensure the fields key is added to the same searchParams object passed into
useBrowsePosts.

In `@apps/posts/src/views/ContentCalendar/utils/calendar.ts`:
- Around line 106-121: sortCalendarPosts currently mutates its input by calling
posts.sort(); change it to sort a shallow copy instead so the original array
isn't mutated: create a copy of the posts array (e.g., via [...posts] or
posts.slice()) and call .sort() on that copy before returning it; update the
function around the posts parameter and the sort call in sortCalendarPosts to
operate on the copied array so callers receive a new sorted array without
modifying the input.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
apps/posts/src/views/ContentCalendar/utils/calendar.ts (1)

142-157: sortCalendarPosts mutates its input array via .sort().

Currently safe because mapPostsByDay passes a .filter()-produced copy, but this is a subtle coupling. If a future caller passes an array directly, it'll be mutated unexpectedly. Consider using .toSorted() or spreading first.

♻️ Suggested change
 const sortCalendarPosts = (posts: CalendarPost[], order: CalendarPostOrder): CalendarPost[] => {
-    return posts.sort((a, b) => {
+    return [...posts].sort((a, b) => {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/posts/src/views/ContentCalendar/utils/calendar.ts` around lines 142 -
157, sortCalendarPosts currently calls posts.sort(...) which mutates the input
array; change it to return a new sorted array instead (e.g., use
posts.toSorted(...) if available or return [...posts].sort(...)) so callers
aren't surprised by in-place mutation. Update the implementation inside the
sortCalendarPosts function to operate on a non-mutating copy and preserve the
existing comparison logic for 'updated_at desc', 'published_at asc', and the
default branch.
apps/posts/test/unit/utils/content-calendar.test.ts (1)

56-70: Consider adding a test for 'updated_at desc' ordering.

You cover published_at desc (default) and published_at asc, but updated_at desc — the third CalendarPostOrder variant — has no test. It has distinct logic (falls back to occursAt when updatedAt is absent).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/posts/test/unit/utils/content-calendar.test.ts` around lines 56 - 70,
Add a unit test that verifies ordering when order is 'updated_at desc' by
calling buildCalendarGrid with order: 'updated_at desc' and posts where one post
has updated_at and another lacks updated_at so the logic falls back to occursAt;
use createPost to craft posts (e.g., one with updated_at later and one without
updated_at but different occursAt) locate the target day via grid.find(day =>
day.dateKey === 'YYYY-MM-DD') and assert the returned day.posts map yields the
correct order (post with later updated_at first, otherwise ordered by occursAt).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@apps/posts/src/views/ContentCalendar/utils/calendar.ts`:
- Around line 142-157: sortCalendarPosts currently calls posts.sort(...) which
mutates the input array; change it to return a new sorted array instead (e.g.,
use posts.toSorted(...) if available or return [...posts].sort(...)) so callers
aren't surprised by in-place mutation. Update the implementation inside the
sortCalendarPosts function to operate on a non-mutating copy and preserve the
existing comparison logic for 'updated_at desc', 'published_at asc', and the
default branch.

In `@apps/posts/test/unit/utils/content-calendar.test.ts`:
- Around line 56-70: Add a unit test that verifies ordering when order is
'updated_at desc' by calling buildCalendarGrid with order: 'updated_at desc' and
posts where one post has updated_at and another lacks updated_at so the logic
falls back to occursAt; use createPost to craft posts (e.g., one with updated_at
later and one without updated_at but different occursAt) locate the target day
via grid.find(day => day.dateKey === 'YYYY-MM-DD') and assert the returned
day.posts map yields the correct order (post with later updated_at first,
otherwise ordered by occursAt).

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (3)
apps/posts/src/views/ContentCalendar/content-calendar.tsx (3)

163-183: URL-sourced tag and author values are interpolated directly into the NQL filter string.

tag and author are read from searchParams.get(...) and spliced straight into the NQL expression (e.g., tag:${tag}, authors:${author}). A crafted URL like ?tag=news%2Bstatus:draft would produce tag:news+status:draft, injecting an extra clause into the filter.

Ghost's API enforces role-based access server-side, so this isn't a data-leakage vector, but it can cause confusing query behavior and bypasses the UI's intended filter semantics. Consider validating or encoding these values before interpolation (e.g., rejecting values that contain NQL operators like +, :, [, ]).

Suggested validation helper
+const NQL_OPERATOR_PATTERN = /[+:\[\]()]/;
+
+const sanitizeNqlValue = (value: string | null): string | null => {
+    if (!value || NQL_OPERATOR_PATTERN.test(value)) {
+        return null;
+    }
+    return value;
+};

Then use it when reading params:

-    if (tag) {
-        parts.push(`tag:${tag}`);
+    const safeTag = sanitizeNqlValue(tag);
+    if (safeTag) {
+        parts.push(`tag:${safeTag}`);
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/posts/src/views/ContentCalendar/content-calendar.tsx` around lines 163 -
183, The current assembly of the NQL filter in the function building parts
(using variables tag, author and returning parts.join('+')) interpolates raw URL
values directly (e.g., `tag:${tag}` and `authors:${author}`) which allows
injection of NQL operators; update the logic that reads tag and author to
validate or encode them before pushing into parts: add a small helper (e.g.,
isSafeNqlValue or encodeNqlValue) and call it when handling tag and author (and
any other URL-derived fragments) so you either reject values containing
characters like '+', ':', '[', ']' or safely escape/encode them before
constructing the filter; ensure you still respect the existing branches
(type/featured, visibility, shouldForceCurrentUser/currentUserSlug, author) and
only push validated/encoded values into parts so parts.join('+') cannot be
manipulated by crafted query strings.

667-683: Empty state replaces the calendar grid entirely — consider showing empty day cells.

When posts.length === 0, the grid is swapped for an EmptyIndicator. While the MonthNavigation remains visible (users can still switch months), the absence of day cells removes spatial context that a calendar view typically provides. Showing the empty grid alongside a subtle message may feel more natural for a calendar UI.

This is a minor UX consideration — feel free to defer if the current behavior is intentional.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/posts/src/views/ContentCalendar/content-calendar.tsx` around lines 667 -
683, The current conditional (posts.length === 0) replaces the entire calendar
grid with an EmptyIndicator; update the rendering so the calendar grid/day cells
are still rendered (keep MonthNavigation and day cells) and show the
EmptyIndicator as a subtle overlay or as a cell-level message when there are no
posts. Concretely, remove or narrow the posts.length === 0 block around the
whole grid and instead render the EmptyIndicator inside the calendar container
(or in empty day cells) while preserving the existing props/controls
(hasActiveFilters, clearFilters, Button, LucideIcon.CalendarDays) so users
retain spatial context of the month.

464-476: Posts query fires before currentUser resolves, causing redundant fetch for Author/Contributor users.

isCurrentUserAuthorOrContributor defaults to false while currentUserQuery is loading (Line 465), so the initial postQueryFilter omits the authors: clause. Once the user loads, the filter updates and React Query refires the request. For Admin/Editor users this is harmless (they see all posts anyway), but for Author/Contributor roles it causes a wasted initial fetch.

Consider gating the posts query on currentUserQuery being resolved:

Suggested fix
 const {data, isError, isLoading} = useBrowsePosts({
+    enabled: Boolean(currentUser),
     searchParams: {
         filter: postQueryFilter,
         limit: 'all',
         order: calendarOrder
     }
 });

Also applies to: 574-580

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/posts/src/views/ContentCalendar/content-calendar.tsx` around lines 464 -
476, The posts query is firing before currentUser resolves because
isCurrentUserAuthorOrContributor is computed while currentUserQuery is still
loading; update the logic so calendarFilter (built by buildCalendarFilter) and
the posts query are gated on currentUserQuery resolution: compute
isCurrentUserAuthorOrContributor only after currentUserQuery.isSuccess (or
include currentUserQuery.isLoading/isSuccess in the useMemo deps) and pass
shouldForceCurrentUser as undefined/false while loading, and also add an
enabled: !currentUserQuery.isLoading (or enabled: currentUserQuery.isSuccess) to
the posts query that consumes calendarFilter so the initial fetch is skipped
until currentUser is resolved. Ensure you modify the symbols
isCurrentUserAuthorOrContributor, calendarFilter, buildCalendarFilter and the
posts query options accordingly.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@apps/posts/src/views/ContentCalendar/content-calendar.tsx`:
- Around line 163-183: The current assembly of the NQL filter in the function
building parts (using variables tag, author and returning parts.join('+'))
interpolates raw URL values directly (e.g., `tag:${tag}` and
`authors:${author}`) which allows injection of NQL operators; update the logic
that reads tag and author to validate or encode them before pushing into parts:
add a small helper (e.g., isSafeNqlValue or encodeNqlValue) and call it when
handling tag and author (and any other URL-derived fragments) so you either
reject values containing characters like '+', ':', '[', ']' or safely
escape/encode them before constructing the filter; ensure you still respect the
existing branches (type/featured, visibility,
shouldForceCurrentUser/currentUserSlug, author) and only push validated/encoded
values into parts so parts.join('+') cannot be manipulated by crafted query
strings.
- Around line 667-683: The current conditional (posts.length === 0) replaces the
entire calendar grid with an EmptyIndicator; update the rendering so the
calendar grid/day cells are still rendered (keep MonthNavigation and day cells)
and show the EmptyIndicator as a subtle overlay or as a cell-level message when
there are no posts. Concretely, remove or narrow the posts.length === 0 block
around the whole grid and instead render the EmptyIndicator inside the calendar
container (or in empty day cells) while preserving the existing props/controls
(hasActiveFilters, clearFilters, Button, LucideIcon.CalendarDays) so users
retain spatial context of the month.
- Around line 464-476: The posts query is firing before currentUser resolves
because isCurrentUserAuthorOrContributor is computed while currentUserQuery is
still loading; update the logic so calendarFilter (built by buildCalendarFilter)
and the posts query are gated on currentUserQuery resolution: compute
isCurrentUserAuthorOrContributor only after currentUserQuery.isSuccess (or
include currentUserQuery.isLoading/isSuccess in the useMemo deps) and pass
shouldForceCurrentUser as undefined/false while loading, and also add an
enabled: !currentUserQuery.isLoading (or enabled: currentUserQuery.isSuccess) to
the posts query that consumes calendarFilter so the initial fetch is skipped
until currentUser is resolved. Ensure you modify the symbols
isCurrentUserAuthorOrContributor, calendarFilter, buildCalendarFilter and the
posts query options accordingly.

Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Bugbot Autofix is OFF. To automatically fix reported issues with Cloud Agents, enable Autofix in the Cursor dashboard.

)}
<MonthNavigation monthLabel={monthLabel} monthOffset={monthOffset} setMonthOffset={setMonthOffset} />
</div>
{posts.length === 0 ? (
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Empty state check uses API count, not visible posts

Medium Severity

The legend and empty-state checks rely on posts.length (raw API results), but getCalendarDateRangeFilter fetches posts with a full one-month buffer using an OR across published_at, updated_at, and created_at. Meanwhile, getPostOccurrenceDate places posts on the grid using a single status-dependent date field. A published post from the prior month with a recent updated_at gets fetched (matches updated_at range) but placed on a day outside the visible 42-cell grid (using published_at). The post inflates posts.length, so the legend renders and the empty state never appears — the user sees a blank grid with no explanation.

Additional Locations (1)

Fix in Cursor Fix in Web

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

Comments